Skip to content

fix(mobile): Android Chrome keyboard collapse, Enter handling, and focus API - #3025

Closed
YousefED wants to merge 62 commits into
mainfrom
mobile/android-keyboard-fixes
Closed

YousefED wants to merge 62 commits into
mainfrom
mobile/android-keyboard-fixes

Conversation

@YousefED

@YousefED YousefED commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes the Android Chrome keyboard/popover collapse at its root, plus the editing and focus bugs found while chasing it, and adds the mobile test infrastructure that keeps them fixed.

Branched off mobile-toolbar-demo, and supersedes #2982.

#2982 approached the collapse from the symptom end — autoFocus={false} on the link URL input, plus an onClickCapture handler that scrolls the input into view and restores the editor scroll a second later, with the onClickCapture prop plumbed through ComponentsContext and all three UI packages' TextInputs. This PR fixes the root cause instead (see below), which makes all of that unnecessary: it costs autofocus on desktop, and adds public API surface for a workaround nothing needs any more.

The one part of #2982 that is not superseded — the pointer: coarse rule that keeps .bn-form-popover inputs at 16px so iOS Safari doesn't auto-zoom on focus — is cherry-picked here with Matthew's authorship intact, and now has the test coverage it previously lacked. So #2982 can be closed once this lands.

Fixes

Mantine popover collapse (the original bug). Mantine's hideDetached (default true) reacts to the viewport resize the on-screen keyboard causes by setting display: none on the still-mounted dropdown. A display: none ancestor can't hold focus, so the popover's input blurred, the toolbar unmounted, and the keyboard dismissed — the whole collapse cascade. Disabling hideDetached for portalled (mobile) popovers fixes it at the source.

Enter on Android (#3001). prosemirror-view ignores Enter keydowns on Android Chrome and falls back to parsing the browser's native DOM split, which misparses BlockNote's nested block DOM and corrupts the document — Enter inserting a space, doing nothing, or breaking tables. BlockNote now intercepts the beforeinput instead and dispatches through the keymap chain, restoring the domObserver.forceFlush() parity prosemirror's Android bail skips. Verified as not a recent regression: the same race exists in every version pairing back to BlockNote 0.29 / prosemirror-view 1.38.1, bisected with era-correct overrides.

Public focus API. editor.isFocused({ includeFloatingUI }) and editor.onFocusChange(cb, { includeFloatingUI }) answer "is the user still interacting with this editor" — content focus or focus inside the editor's own floating UI. Replaces reaching into editor._tiptapEditor.on("focus"/"blur"), which can't see focus moving into a portalled popover. Events are settled (a rAF re-check, because document.activeElement is transiently <body> mid-handoff and relatedTarget is unreliable on mobile). The React callback hooks move to the latest-ref pattern, so unmemoized callbacks no longer resubscribe on every render.

iOS input auto-zoom (cherry-picked from #2982). Safari zooms the page when an input with a computed font-size below 16px takes focus, and that zoom perturbs the visual viewport the mobile toolbar positions itself from.

Selected link URL. Reading the URL from a single boundary position is fragile twice over: marks() excludes a link at its left edge, and engines disagree by one position on where a selection over a link starts. Now scans the selected range for the first link mark, which fixes the link popover opening empty for a fully selected link on WebKit.

Test infrastructure

Android-emulated browser instance. A fourth e2e instance (Android UA, touch, phone viewport) so isTouchDevice() is genuinely true and prosemirror takes its Android code paths — no stubs. It runs the new end-to-end/mobile/ suites plus the behavioral suites where Android genuinely differs. Note the per-instance contextOptions are silently ignored by vitest; the instance needs its own provider: playwright({ contextOptions }).

Real-device suite on BrowserStack (tests/device/, pnpm run test:device). A dependency-free WebDriver REST client, a gesture layer holding every platform quirk, and suites for the toolbar lifecycle and basic editing including soft-keyboard Enter. Self-skips without credentials, so it's safe to invoke anywhere. Nightly workflow included (needs BROWSERSTACK_* secrets).

Verification

Every fix has an e2e test proven to fail without it — each was verified by reverting the fix and confirming the test reports the original symptom. Android instance: 45 passed, 4 expected skips. Also verified on real devices (Samsung Galaxy S22, iPhone 16e) for the Enter and popover fixes.

The iOS auto-zoom fix is covered at two levels, since the rule and the behaviour it prevents are testable in different places: the emulated suite asserts the input's computed font-size stays at/above 16px (the CSS contract), and the real-device suite asserts visualViewport.scale doesn't increase when the URL input takes focus (the actual behaviour).

Draft: a follow-up branch stacks ~27 more commits on top of this one (comments/toolbar handoff, IME composition coverage, visual baselines, accessibility scanning, a tablet device target).

matthewlipski and others added 30 commits August 3, 2026 18:58
- Made mobile toolbar no longer experimental & part of default UI
- Updated example
- Removed hover styles for mobile
- Gated comment and link buttons to only show when selection is not empty
…ple (#2985)

* docs: name the mobile toolbar layouts, add layout toggle to example

Introduce "scrolling document" (default) vs "pinned scroll container"
(opt-in) as the names for the two page layouts the mobile formatting
toolbar supports, and restructure the docs section around them.

- Docs: simple-first rewrite of the Mobile Formatting Toolbar section
  (default layout, then the opt-in layout with its two CSS rules).
- Example: stop embedding it in the docs (`docs: false`) - its
  page-level CSS (html/body overflow, full-viewport fixed scroll host,
  `.prose` rules) leaks into the docs page since examples render inline.
  Link to the standalone playground example instead.
- Example: add a nav-bar switch that toggles the pinned scroll container
  layout via a class on <html>, so both layouts can be compared.
- Playground: `.mantine-AppShell-root` width 100vw -> 100%, which caused
  a horizontal scrollbar on any example taller than the viewport.
- Align README, JSDoc and example comments with the new naming;
  regenerate examples.gen.tsx.

* Implemented PR feedback

---------

Co-authored-by: Matthew Lipski <matthewlipski@gmail.com>
`useEditorFocusChange` only offered a callback, so anything that wanted
to *render* based on focus had to wire up useState + useEffect itself —
which MobileFormattingToolbarController did, including a manual re-sync
for focus that changed before the subscription attached.

`useEditorFocus(options?, editor?)` returns that as state. The two hooks
now mirror the split the codebase already has between `useEditorState`
and `useEditorChange`: state for rendering, callback for side effects.

Built on useSyncExternalStore, so there's no extra render on mount and
no tearing. The snapshot is deliberately the last *settled* value rather
than a live `isFocused()` read: reading focus during an arbitrary render
can catch a mid-handoff frame where document.activeElement is
transiently <body> and the editor looks unfocused.

The controller now reads as one line, and no longer needs the editor
instance at all.

Covered by browser tests for the behaviours that justify the hook:
content focus/blur, staying focused across a handoff into portalled UI,
not re-rendering for unrelated focus changes elsewhere on the page
(these are document-level listeners, so it sees them all), and not
re-rendering while typing.
Adds a regression test for the latest-ref pattern in
`useEditorFocusChange`: an inline callback (a new identity every render)
must not cause the editor subscription to be torn down and re-attached.

Measured both ways before writing it — the naive implementation, with
the callback in the dependency array, resubscribes once per render (6
after 5 re-renders); the latest-ref version stays at 1. That churn is
worse than it looks with `includeEditorUI`, where the subscription is
reference-counted: each cycle detaches and re-attaches the document
focus listeners and resets the settled baseline.

Also corrects a comment in useEditorFocus: React re-checks the snapshot
*after* subscribing (its subscribe effect is registered before the
consistency-check effect — verified in react-dom's source), so the
re-sync inside subscribe is what makes the "focus changed between
render and subscription" case work.
… for

The previous wording said it covers "UI rendered as a sibling of the
content" without saying which UI that is — and BlockNote's own default
UI is entirely portalled (a mounted editor's container has exactly two
children: the content element and the portal), so on its own that
justification doesn't hold up.

The real case is UI the host app passes as `BlockNoteView` children,
which React renders as siblings of the content element. The shipped
"Static Formatting Toolbar" example is exactly that: `<FormattingToolbar />`
rendered inline rather than through a controller, putting eleven
focusable buttons next to the content. Without the hop, focusing one of
them reads as "outside the editor", which would dismiss the mobile
toolbar mid-interaction and trip the side menu's click-outside check.
Opening a toolbar popover reset the page scroll to the top, taking the
block being edited off screen entirely. Measured on the
mobile-formatting-toolbar example: scrollTop 451 -> 0, putting the
edited editor at y=800 in a 427px viewport.

Cause: the input's native `autofocus` fires while floating-ui has not
positioned the popover yet, so the browser scrolls to the popover's
pre-positioned spot — the top of the container — instead of where it
ends up. Focusing through a ref with `{ preventScroll: true }` keeps
the page still; floating-ui positions the popover regardless, so it
still appears in the right place.

Applied in all three UI packages' form TextInput, since they all take
`autoFocus` for popover inputs (link, file embed, rename, caption).

Covered by a mobile e2e test asserting neither the scroll position nor
the edited editor moves when the popover opens — proven red without the
fix (451 -> 0). Reported from a real device.
Reported from a device: select text in the first of two editors, open
the link popover, type a URL, press Enter — no link is created and
focus jumps to the *second* editor.

Android's IME chooses the Enter key's action itself. With no
surrounding <form> and no enterkeyhint, Chrome picks IME_ACTION_NEXT
whenever another focusable element follows, and Next advances focus
rather than dispatching a key event — so the keydown handler that
creates the link never runs. That also explains the asymmetry in the
report: from the last editor on the page nothing focusable follows, so
Chrome picks Done instead, Enter is dispatched, and the link is created
as expected.

Setting enterkeyhint="done" on the popover inputs makes the key report
itself as Enter everywhere. Applied in all three UI packages, so it
covers the link, file embed, rename and caption popovers.

The mobile toolbar test now asserts the attribute — a DOM-contract
check rather than a behavioural one, since emulation always dispatches
a real Enter and so cannot reproduce the IME's action choice.
Replaces the enterkeyhint attribute assertion with a test of what the
user actually reported: opening the link popover from the first of two
editors, submitting, and expecting the link in *that* editor with focus
still there — rather than the link missing and focus in the second one.

The attribute assertion stays, but as one line inside that behavioural
test rather than as the test itself, because it is the only part of the
IME contract a test can hold onto. The device-only half — which action
Android assigns to the Enter key — is not reachable from any automated
environment we have: emulation always dispatches a real Enter, and on
BrowserStack no input channel reaches the on-screen keyboard. That half
is now a release-checklist item in the device README instead of being
silently uncovered.
Follow-up to the enterkeyhint fix, which had no red-first test: nothing
in an emulated browser can reproduce an IME choosing to advance focus
instead of dispatching Enter, so the only thing a test could assert was
the attribute itself.

The deeper problem is that these popovers had no submission path at all
besides a keydown listener. `Form.Root` rendered a plain `<div>`, so
the `onSubmit` prop the TextInputs already accepted could never fire —
`submit` is dispatched on forms, not inputs. When a platform reports
Enter-to-submit as a form submission rather than a key event, nothing
happened.

`Form.Root` now renders a real `<form>` and takes `onSubmit`, wired up
in all four callers (link, file rename, file caption, AI prompt). It
always preventDefaults, so a caller that doesn't pass a handler can
never navigate away. The dead per-input `onSubmit` props are removed.
This also gives the browser proper form context, which is what it uses
to decide the IME's action key in the first place.

Now testable without any key event: fill the popover, call
`form.requestSubmit()` — exactly what the browser does when the IME
action means submit — and assert the link is created. Proven red with
`Form.Root` back to a `<div>` ("the popover must be a real <form>").

This branch was successfully deployed

2 active deployments
Preview – blocknote-website — 12a8a4aa Deployed Aug 31, 2026 by vercel[bot]
Preview – blocknote — 12a8a4aa Deployed Aug 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants